音频组件优化:移动端事件绑定&控制按钮定制&useAudioPlayer
概述
本节从三个维度优化音频组件:(1) 移动端触摸事件绑定;(2) 控制按钮的定制化显示/隐藏;(3) 将复杂播放逻辑封装为 useAudioPlayer composable。
优化一:移动端事件绑定
问题
进度条和音量滑块在移动端只绑定了 mousedown,触摸操作无响应。
解决方案
同时绑定 touch 和 mouse 事件,确保跨设备兼容:
// ProgressBar.vue 中添加触摸事件支持
function bindEvents(el: HTMLElement) {
// 鼠标事件(PC)
el.addEventListener('mousedown', handleStart)
// 触摸事件(移动端)
el.addEventListener('touchstart', handleTouchStart, { passive: false })
}
function handleTouchStart(e: TouchEvent) {
e.preventDefault() // 防止滚动
const touch = e.touches[0]
updatePosition(touch.clientX)
isDragging.value = true
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd)
}
function handleTouchMove(e: TouchEvent) {
e.preventDefault()
if (!isDragging.value) return
const touch = e.touches[0]
updatePosition(touch.clientX)
}
function handleTouchEnd() {
isDragging.value = false
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
typescript
响应式布局调整
/* PC 端:水平排列所有控件 */
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
/* 移动端:循环按钮移到顶部,紧凑排列 */
@media (max-width: 640px) {
.audio-controls {
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.loop-control {
order: -1; /* 移到最前面 */
}
}
css
优化二:控制按钮定制
通过 Props 数组控制显示
// 用户可传入需要显示的控制按钮
const props = defineProps<{
controls?: AudioControlType[]
}>()
// 默认显示全部按钮
const visibleControls = computed(() => {
const all: AudioControlType[] = [
'prev', 'backward', 'play', 'forward', 'next',
'volume', 'rate', 'loop'
]
return props.controls?.length ? props.controls : all
})
typescript
使用示例
<!-- 精简模式:只显示播放和进度 -->
<AudioPlayer src="audio.mp3" :controls="['play']" />
<!-- 标准模式 -->
<AudioPlayer src="audio.mp3" :controls="['prev', 'play', 'next', 'loop']" />
<!-- 完整模式 -->
<AudioPlayer src="audio.mp3" />
vue
优化三:useAudioPlayer Composable
封装复杂逻辑
将播放控制、列表管理、模式切换等逻辑抽取为独立的 composable:
// composables/useAudioPlayer.ts
import { ref, reactive, computed, watch, onUnmounted } from 'vue'
import { Howl, Howler } from 'howler'
import type { AudioListItem, AudioPlayerState } from './types'
export function useAudioPlayer(options?: {
list?: AudioListItem[]
autoplay?: boolean
}) {
const audioInstance = ref<Howl | null>(null)
const currentIndex = ref(0)
const list = ref(options?.list || [])
const loopMode = ref(0)
const state = reactive<AudioPlayerState>({
playing: false,
progress: 0,
duration: 0,
volume: 1,
rate: 1,
muted: false,
loading: false
})
// 当前曲目
const currentTrack = computed(() => list.value[currentIndex.value])
// 进度百分比
const progressPercent = computed({
get: () => state.duration > 0 ? state.progress / state.duration : 0,
set: (val: number) => {
state.progress = val * state.duration
if (audioInstance.value) {
audioInstance.value.seek(state.progress)
}
}
})
// 进度更新定时器
let rafId: number | null = null
function startProgressUpdate() {
const update = () => {
if (audioInstance.value && state.playing) {
state.progress = audioInstance.value.seek() as number
}
rafId = requestAnimationFrame(update)
}
rafId = requestAnimationFrame(update)
}
function stopProgressUpdate() {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// 初始化音频
function initAudio(src: string | string[]) {
stopProgressUpdate()
audioInstance.value?.unload()
state.loading = true
audioInstance.value = new Howl({
src: Array.isArray(src) ? src : [src],
volume: state.volume,
rate: state.rate,
loop: loopMode.value === 2,
onload() {
state.duration = audioInstance.value!.duration()
state.loading = false
},
onplay() {
state.playing = true
startProgressUpdate()
},
onpause() {
state.playing = false
stopProgressUpdate()
},
onstop() {
state.playing = false
stopProgressUpdate()
},
onend() {
state.playing = false
stopProgressUpdate()
handleTrackEnd()
}
})
}
// 播放控制
function play() { audioInstance.value?.play() }
function pause() { audioInstance.value?.pause() }
function togglePlay() { state.playing ? pause() : play() }
// 步进
function stepForward() {
const current = (audioInstance.value?.seek() as number) || 0
const target = Math.min(current + 15, state.duration)
audioInstance.value?.seek(target)
state.progress = target
}
function stepBackward() {
const current = (audioInstance.value?.seek() as number) || 0
const target = Math.max(current - 15, 0)
audioInstance.value?.seek(target)
state.progress = target
}
// 音量 & 速率
function setVolume(val: number) {
state.volume = val
audioInstance.value?.volume(val)
}
function setRate(val: number) {
state.rate = val
audioInstance.value?.rate(val)
}
// 列表控制
function handleNext() {
const len = list.value.length
if (len === 0) return
if (loopMode.value === 1) {
currentIndex.value = (currentIndex.value + 1) % len
} else if (loopMode.value === 3) {
let next: number
do { next = Math.floor(Math.random() * len) }
while (next === currentIndex.value && len > 1)
currentIndex.value = next
} else {
if (currentIndex.value < len - 1) currentIndex.value++
}
}
function handlePrev() {
const len = list.value.length
if (len === 0) return
if (loopMode.value === 1) {
currentIndex.value = (currentIndex.value - 1 + len) % len
} else if (loopMode.value === 3) {
let prev: number
do { prev = Math.floor(Math.random() * len) }
while (prev === currentIndex.value && len > 1)
currentIndex.value = prev
} else {
if (currentIndex.value > 0) currentIndex.value--
}
}
function handleTrackEnd() {
if (loopMode.value === 2) return // 单曲循环由 Howler 处理
if (loopMode.value === 0 && currentIndex.value >= list.value.length - 1) return
handleNext()
}
function toggleLoopMode() {
const prev = loopMode.value
loopMode.value = (loopMode.value + 1) % 4
if (prev === 2 && audioInstance.value) audioInstance.value.loop(false)
if (loopMode.value === 2 && audioInstance.value) audioInstance.value.loop(true)
}
// 监听曲目变化
watch(currentIndex, (idx) => {
if (list.value[idx]) {
initAudio(list.value[idx].src)
if (state.playing || options?.autoplay) {
audioInstance.value?.play()
}
}
})
// 清理
onUnmounted(() => {
stopProgressUpdate()
audioInstance.value?.unload()
})
return {
state,
currentIndex,
list,
loopMode,
currentTrack,
progressPercent,
// 控制方法
play, pause, togglePlay, stop: () => audioInstance.value?.stop(),
stepForward, stepBackward,
setVolume, setRate,
handleNext, handlePrev,
toggleLoopMode,
initAudio
}
}
typescript
使用 composable
<template>
<div class="audio-player">
<ProgressBar v-model="progressPercent" :duration="state.duration" />
<ControlButtons :playing="state.playing" @toggle-play="togglePlay" />
</div>
</template>
<script setup lang="ts">
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import ProgressBar from './ProgressBar.vue'
const {
state, progressPercent, togglePlay,
handleNext, handlePrev
} = useAudioPlayer({
list: [
{ src: 'song1.mp3', title: '歌曲 1' },
{ src: 'song2.mp3', title: '歌曲 2' }
]
})
</script>
vue
小结
- 移动端通过绑定
touchstart/touchmove/touchend实现滑块拖拽 - 控制按钮通过 Props 数组实现按需显示,灵活定制 UI
useAudioPlayercomposable 封装全部播放逻辑,组件只负责 UI 渲染- composable 返回响应式状态和控制方法,模板直接使用
↑